Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

34장. 도메인 객체 설계하기

도메인 객체는 서비스의 심장입니다.
회원, 상품, 주문 같은 핵심 개념을 담습니다.

DTO가 “데이터를 나르는 그릇“이라면,
도메인 객체는 “규칙을 지키는 주인공“입니다.

이 장에서는 도메인 객체를
어떻게 튼튼하게 설계하는지 배웁니다.

핵심 주제는 하나입니다.

잘못된 상태가 애초에 만들어지지 않게 하라.


34.1 데이터를 담는 객체와 행동을 가진 객체

객체에는 두 가지 성격이 있습니다.

  • 데이터만 담는 객체 (예: DTO)
  • 데이터 + 행동을 함께 가진 객체 (예: 도메인)

DTO는 값만 실어 나르면 됩니다.
하지만 도메인은 다릅니다.

도메인은 “자기 규칙“을 스스로 지켜야 합니다.

class Order(
    val id: Long,
    val items: List<OrderItem>,
    var status: OrderStatus
) {
    fun cancel() {
        if (status == OrderStatus.COMPLETED) {
            throw IllegalStateException("완료된 주문은 취소할 수 없습니다")
        }
        status = OrderStatus.CANCELED
    }
}

cancel()이라는 행동이
“완료된 주문은 취소 불가“라는 규칙을 품고 있습니다.

이렇게 규칙을 객체 안에 두는 것이
좋은 도메인 설계의 시작입니다.


34.2 상태를 외부에서 변경하지 못하게 하기

도메인의 상태를
바깥에서 마음대로 바꾸면 규칙이 깨집니다.

// 나쁜 예: 바깥에서 아무렇게나 바꿀 수 있음
order.status = OrderStatus.CANCELED

이러면 cancel()의 규칙을 우회하게 됩니다.

그래서 상태 변경은
반드시 메서드를 통하게 만듭니다.
바깥에서 직접 못 바꾸게 막는 것입니다. (7장 접근 제어)

class Order(
    val id: Long,
    val items: List<OrderItem>,
    status: OrderStatus
) {
    var status: OrderStatus = status
        private set   // 바깥에서 쓰기 금지, 읽기만 허용

    fun cancel() { ... }
}

private set 덕분에
상태는 오직 cancel() 같은 메서드로만 바뀝니다.


34.3 생성 시점에 유효한 객체 만들기

가장 좋은 방어는
“애초에 잘못된 객체를 못 만들게” 하는 것입니다.

생성 시점에 검증을 넣습니다.
init 블록을 활용합니다. (7장)

class User(
    val id: Long,
    val name: String,
    val email: String
) {
    init {
        require(name.isNotBlank()) { "이름은 비어 있을 수 없습니다" }
        require(email.contains("@")) { "올바른 이메일이 아닙니다: $email" }
    }
}

require는 조건이 거짓이면
예외를 던지는 함수입니다.

이제 이름이 비었거나
이메일 형식이 틀린 User
아예 만들어지지 않습니다.

유효하지 않은 객체가 존재할 수 없게 만들면,
그 뒤 코드에서는 안심하고 쓸 수 있습니다.


34.4 Enum으로 상태 표현하기

주문 상태처럼
“정해진 몇 가지 값 중 하나“는
Enum으로 표현합니다. (10장)

enum class OrderStatus {
    CREATED,     // 생성됨
    COMPLETED,   // 완료됨
    CANCELED     // 취소됨
}

문자열로 상태를 다루면
오타나 잘못된 값이 들어올 수 있습니다.

// 나쁜 예: 문자열은 오타에 취약
var status: String = "COMPLTED"   // 오타!

Enum을 쓰면
정해진 값만 쓸 수 있어 안전합니다.


34.5 Value Object 만들기

값 자체가 하나의 개념일 때
값 객체(Value Object)로 감싸면 좋습니다.

예를 들어 “돈“을 그냥 Int로 두면
음수 금액 같은 실수가 생길 수 있습니다.

@JvmInline
value class Money(val amount: Int) {
    init {
        require(amount >= 0) { "금액은 음수일 수 없습니다" }
    }
}

value class
값을 감싸면서도 성능 부담이 거의 없는 특별한 클래스입니다.

이제 금액은 항상 Money로 다뤄지고,
음수 금액은 존재할 수 없습니다.

값에 의미와 규칙을 부여하는 것,
이것이 값 객체의 힘입니다.


34.6 불변성을 활용한 모델링

2장에서 배운 불변성을
도메인에도 적극 활용합니다.

상태가 바뀌는 대신
“새 상태의 객체를 만든다“는 접근입니다. (9장 copy)

주문을 예로,
다음 흐름을 구현해 봅시다.

주문 생성
주문 완료
주문 취소
취소할 수 없는 주문 검증
data class Order(
    val id: Long,
    val items: List<OrderItem>,
    val status: OrderStatus = OrderStatus.CREATED
) {
    fun complete(): Order {
        check(status == OrderStatus.CREATED) { "생성 상태에서만 완료할 수 있습니다" }
        return copy(status = OrderStatus.COMPLETED)
    }

    fun cancel(): Order {
        check(status != OrderStatus.COMPLETED) { "완료된 주문은 취소할 수 없습니다" }
        return copy(status = OrderStatus.CANCELED)
    }
}

complete()cancel()
기존 객체를 바꾸지 않고
copy로 새 객체를 돌려줍니다.

val order = Order(id = 1, items = items)
val completed = order.complete()   // order는 그대로, 새 객체 반환

check는 상태 조건을 검사하는 함수입니다.
require가 “입력값“을 검사한다면,
check는 “현재 상태“를 검사합니다.

이렇게 불변 + 규칙으로 설계하면
주문이 잘못된 상태로 빠질 일이 없습니다.


34장을 마치며

이 장에서 우리는 다음을 배웠습니다.

  • 도메인은 데이터에 더해 규칙(행동)을 가진다는 점
  • private set으로 상태를 보호하는 법
  • initrequire로 생성 시점에 검증하기
  • Enum과 값 객체로 안전하게 표현하기
  • 불변성과 copy로 상태 전이를 모델링하기

다음 장에서는
도메인을 저장하고 꺼내는 Repository를 설계합니다.